game_manager_lib\commands\recommendation/
core.rs1use crate::database::AppState;
9use crate::errors::AppError;
10use crate::models::Game;
11use crate::services::recommendation::{
12 calculate_user_profile, parse_release_year, rank_games_collaborative, rank_games_content_based,
13 rank_games_hybrid, GameWithDetails, RecommendationConfig, RecommendationReason, SeriesLimit,
14 UserPreferenceVector, UserSettings,
15};
16use serde::{Deserialize, Serialize};
17use std::collections::HashSet;
18use tauri::{Manager, State};
19
20#[derive(Debug, Serialize)]
24pub struct GameRecommendation {
25 pub game_id: String,
26 pub score: f32,
27 pub reason: RecommendationReason,
28}
29
30#[derive(Debug, Deserialize)]
32pub struct RecommendationOptions {
33 pub min_playtime: Option<i32>,
34 pub max_playtime: Option<i32>,
35 pub limit: usize,
36 pub ignored_game_ids: Option<Vec<String>>, pub config: Option<RecommendationConfig>, }
39
40#[tauri::command]
46pub async fn recommend_hybrid_library(
47 app: tauri::AppHandle,
48 state: State<'_, AppState>,
49 options: RecommendationOptions,
50) -> Result<Vec<GameRecommendation>, AppError> {
51 let games_with_details = fetch_all_games_with_details(&state)?;
52 let ignored_ids = create_ignored_set(options.ignored_game_ids.clone());
53 let profile = calculate_user_profile(&games_with_details, &ignored_ids);
54 let (cf_scores, _) = crate::services::cf_aggregator::build_cf_candidates(&games_with_details);
55 let candidates = filter_candidates_by_playtime(games_with_details, &options);
56 let config = options.config.unwrap_or_default();
57 let user_settings = load_user_settings(&app);
58
59 let ranked = rank_games_hybrid(
60 &profile,
61 &candidates,
62 &cf_scores,
63 &ignored_ids,
64 config,
65 user_settings,
66 );
67
68 Ok(format_recommendations(ranked, options.limit))
69}
70
71#[tauri::command]
73pub async fn recommend_from_library(
74 app: tauri::AppHandle,
75 state: State<'_, AppState>,
76 options: RecommendationOptions,
77) -> Result<Vec<GameRecommendation>, AppError> {
78 let games_with_details = fetch_all_games_with_details(&state)?;
79 let ignored_ids = create_ignored_set(options.ignored_game_ids.clone());
80 let profile = calculate_user_profile(&games_with_details, &ignored_ids);
81 let candidates = filter_candidates_by_playtime(games_with_details, &options);
82 let config = options.config.unwrap_or_default();
83 let user_settings = load_user_settings(&app);
84 let ranked = rank_games_content_based(&profile, &candidates, &config, &user_settings);
85
86 Ok(format_recommendations(ranked, options.limit))
87}
88
89#[tauri::command]
91pub async fn recommend_collaborative_library(
92 app: tauri::AppHandle,
93 state: State<'_, AppState>,
94 options: RecommendationOptions,
95) -> Result<Vec<GameRecommendation>, AppError> {
96 let games_with_details = fetch_all_games_with_details(&state)?;
97 let ignored_ids = create_ignored_set(options.ignored_game_ids.clone());
98 let (cf_scores, _) = crate::services::cf_aggregator::build_cf_candidates(&games_with_details);
99 let candidates = filter_candidates_by_playtime(games_with_details, &options);
100 let user_settings = load_user_settings(&app);
101 let ranked = rank_games_collaborative(&candidates, &cf_scores, &ignored_ids, &user_settings);
102
103 Ok(format_recommendations(ranked, options.limit))
104}
105
106#[tauri::command]
108pub async fn get_user_profile(
109 state: State<'_, AppState>,
110) -> Result<UserPreferenceVector, AppError> {
111 let games_with_details = fetch_all_games_with_details(&state)?;
112 let ignored_ids = HashSet::new();
113 let profile = calculate_user_profile(&games_with_details, &ignored_ids);
114 Ok(profile)
115}
116
117fn load_user_settings(app_handle: &tauri::AppHandle) -> UserSettings {
121 let app_data_dir = match app_handle.path().app_data_dir() {
123 Ok(dir) => dir,
124 Err(_) => return UserSettings::default(),
125 };
126
127 let prefs_path = app_data_dir.join("user_preferences.json");
128
129 if !prefs_path.exists() {
130 return UserSettings::default();
131 }
132
133 let contents = match std::fs::read_to_string(&prefs_path) {
134 Ok(c) => c,
135 Err(_) => return UserSettings::default(),
136 };
137
138 let prefs: serde_json::Value = match serde_json::from_str(&contents) {
139 Ok(p) => p,
140 Err(_) => return UserSettings::default(),
141 };
142
143 let filter_adult = prefs
144 .get("filter_adult_content")
145 .and_then(|v| v.as_bool())
146 .unwrap_or(false);
147
148 let series_limit_str = prefs
149 .get("series_limit")
150 .and_then(|v| v.as_str())
151 .unwrap_or("moderate");
152
153 let series_limit = match series_limit_str {
154 "none" => SeriesLimit::None,
155 "aggressive" => SeriesLimit::Aggressive,
156 _ => SeriesLimit::Moderate,
157 };
158
159 UserSettings {
160 filter_adult_content: filter_adult,
161 series_limit,
162 }
163}
164
165fn fetch_all_games_with_details(state: &AppState) -> Result<Vec<GameWithDetails>, AppError> {
166 let conn = state.library_db.lock()?;
167
168 let mut stmt = conn.prepare(
169 "SELECT
170 g.id, g.name, g.playtime, g.favorite, g.user_rating, g.cover_url,
171 g.platform_id, g.last_played, g.added_at, g.platform,
172 gd.genres, gd.steam_app_id, gd.release_date, gd.series, gd.tags
173 FROM games g
174 LEFT JOIN game_details gd ON g.id = gd.game_id
175 ORDER BY g.name ASC",
176 )?;
177
178 let games: Result<Vec<GameWithDetails>, _> = stmt
179 .query_map([], |row| {
180 let game = Game {
181 id: row.get(0)?,
182 name: row.get(1)?,
183 playtime: row.get(2)?,
184 favorite: row.get(3)?,
185 user_rating: row.get(4)?,
186 cover_url: row.get(5)?,
187 platform_id: row.get(6)?,
188 last_played: row.get(7)?,
189 added_at: row.get(8)?,
190 platform: row
191 .get::<_, String>(9)
192 .unwrap_or_else(|_| "Unknown".to_string()),
193 genres: None,
195 developer: None,
196 install_path: None,
197 executable_path: None,
198 launch_args: None,
199 status: None,
200 is_adult: false,
201 };
202
203 let genres_json: Option<String> = row.get(10)?;
204 let genres: Vec<String> = genres_json
205 .as_ref()
206 .map(|s| {
207 if let Ok(vec) = serde_json::from_str::<Vec<String>>(s) {
209 vec
210 } else {
211 s.split(',')
213 .map(|g| g.trim().to_string())
214 .filter(|g| !g.is_empty())
215 .collect()
216 }
217 })
218 .unwrap_or_default();
219
220 let steam_app_id_str: Option<String> = row.get(11)?;
221 let steam_app_id: Option<u32> = steam_app_id_str.and_then(|s| s.parse().ok());
222
223 let release_date: Option<String> = row.get(12)?;
224 let release_year = release_date.and_then(|d| parse_release_year(&d));
225
226 let series: Option<String> = row.get(13)?;
227
228 let tags_json: Option<String> = row.get(14)?;
230 let tags: Vec<crate::models::GameTag> = tags_json
231 .as_ref()
232 .and_then(|s| serde_json::from_str(s).ok())
233 .unwrap_or_default();
234
235 Ok(GameWithDetails {
236 game,
237 genres,
238 tags,
239 series,
240 release_year,
241 steam_app_id,
242 })
243 })?
244 .collect();
245
246 games.map_err(|e| e.into())
247}
248
249fn create_ignored_set(ignored_game_ids: Option<Vec<String>>) -> HashSet<String> {
250 ignored_game_ids.unwrap_or_default().into_iter().collect()
251}
252
253fn filter_candidates_by_playtime(
254 games: Vec<GameWithDetails>,
255 options: &RecommendationOptions,
256) -> Vec<GameWithDetails> {
257 let min = options.min_playtime.unwrap_or(0);
258 let max = options.max_playtime.unwrap_or(999999);
259
260 games
261 .into_iter()
262 .filter(|g| {
263 let pt = g.game.playtime.unwrap_or(0);
264 pt >= min && pt <= max
265 })
266 .collect()
267}
268
269fn format_recommendations(
270 ranked: Vec<(GameWithDetails, f32, RecommendationReason)>,
271 limit: usize,
272) -> Vec<GameRecommendation> {
273 ranked
274 .into_iter()
275 .take(limit)
276 .map(|(g, score, reason)| GameRecommendation {
277 game_id: g.game.id,
278 score,
279 reason,
280 })
281 .collect()
282}